# High CPU - default remediation, no user consent required # # 1. Throttle priority of top CPU offenders # 2. Stop running deferrable maintenance tasks # 3. Stop in-flight servicing and disk-scan tasks # 4. Cancel an in-flight Defender scan # # No double quotes and no semicolons are used anywhere in this file. The agent # script runner wraps content in double quotes, so an inner double quote ends # the wrapper early and any following text is run as a command. # --- Audit logging ---------------------------------------------------------- # # Every action and skip is written to the Application event log under this # source, in addition to stdout. Without this, automatic tier means invisible # tier, and a helpdesk ticket has nothing to check the endpoint against. # A logging failure must never block remediation, so the event write is the # one tolerated exception in this file. $eventLogSource = 'HighCPURemediationCommon' $eventLogName = 'Application' if (-not [System.Diagnostics.EventLog]::SourceExists($eventLogSource)) { New-EventLog -LogName $eventLogName -Source $eventLogSource -ErrorAction SilentlyContinue } function Write-RemediationEvent { param([string]$Message) Write-Output $Message try { Write-EventLog -LogName $eventLogName -Source $eventLogSource -EntryType Information -EventId 1000 -Message $Message -ErrorAction Stop } catch { # Tolerated. Losing an audit line must not stop remediation itself. } } # --- 1. Throttle priority -------------------------------------------------- # # CANONICAL protected list. This array is the single source of truth and is # kept byte-identical with the copy in HighCPURemediationConfineCores. Because # the script runner sends each file standalone with no shared module state, the # list cannot be imported at runtime, so it is expanded into each script at # build time. Never edit one copy by hand. A drift that drops a security agent # such as CSFalconService or SentinelAgent would let a lever demote or confine # your own EDR. # # The EDR block below (MsMpEng through SentinelStaticEngine) is the shared EDR # core and is kept identical, name for name, with the EDR block in # HighGPURemediationCommon's $neverTouchProcesses. resources/build/ # validate_protected_process_lists.py fails the build if either copy of this # full list or either tier's EDR core drifts. $protected = @( 'System','Idle','Registry','Memory Compression','smss','csrss','wininit', 'winlogon','services','lsass','svchost','dwm','explorer', 'MsMpEng','MsSense','MpDefenderCoreService','NisSrv','SecurityHealthService', 'CSFalconService','CSFalconContainer', 'SentinelAgent','SentinelServiceHost','SentinelStaticEngine', 'powershell','pwsh','conhost','wsmprovhost' ) $before = @{} foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) { try { $before[$p.Id] = $p.TotalProcessorTime.Ticks } catch { } } Start-Sleep -Seconds 2 $usage = @() foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) { try { if (-not $before.ContainsKey($p.Id)) { continue } $cores = ($p.TotalProcessorTime.Ticks - $before[$p.Id]) / 20000000.0 if ($cores -lt 0) { continue } $usage += [pscustomobject]@{ Name = $p.ProcessName Id = $p.Id Cores = [math]::Round($cores, 2) } } catch { } } # Threshold is in cores held, not percent of machine. One full core is only # 1.6 percent of a 64-core host. $targets = $usage | Where-Object { ($protected -notcontains $_.Name) -and ($_.Cores -ge 0.5) } | Sort-Object Cores -Descending | Select-Object -First 3 foreach ($t in $targets) { $p = Get-Process -Id $t.Id -ErrorAction SilentlyContinue if ($null -eq $p) { continue } if ($p.PriorityClass -eq 'BelowNormal' -or $p.PriorityClass -eq 'Idle') { Write-RemediationEvent ('skip: {0} PID {1} is already {2}' -f $t.Name, $t.Id, $p.PriorityClass) continue } # BelowNormal, not Idle. Idle-class threads can be starved indefinitely # under sustained load. $p.PriorityClass = [System.Diagnostics.ProcessPriorityClass]::BelowNormal Write-RemediationEvent ('demoted: {0} PID {1} holding {2} cores set to BelowNormal' -f $t.Name, $t.Id, $t.Cores) } # --- 2 and 3. Defer maintenance and servicing tasks ------------------------ # # Stopping is a deferral. The scheduler triggers each one again later. $deferrableTasks = @( '\Microsoft\Windows\Application Experience\Microsoft Compatibility Appraiser', '\Microsoft\Windows\Application Experience\ProgramDataUpdater', '\Microsoft\Windows\Application Experience\StartupAppTask', '\Microsoft\Windows\Maintenance\WinSAT', '\Microsoft\Windows\Customer Experience Improvement Program\Consolidator', '\Microsoft\Windows\Customer Experience Improvement Program\UsbCeip', '\Microsoft\Windows\Servicing\StartComponentCleanup', '\Microsoft\Windows\Chkdsk\ProactiveScan' ) foreach ($fullPath in $deferrableTasks) { $splitAt = $fullPath.LastIndexOf('\') $taskFolder = $fullPath.Substring(0, $splitAt + 1) $taskName = $fullPath.Substring($splitAt + 1) $task = Get-ScheduledTask -TaskPath $taskFolder -TaskName $taskName -ErrorAction SilentlyContinue if ($null -eq $task) { continue } if ($task.State -ne 'Running') { continue } Stop-ScheduledTask -TaskPath $taskFolder -TaskName $taskName -ErrorAction Stop | Out-Null Write-RemediationEvent ('stopped: {0}' -f $taskName) } # --- 4. Cancel Defender scan ----------------------------------------------- # # The Defender module has no stop verb. MpCmdRun is the only supported way to # cancel a running scan. Real-time protection is not touched. # Defender must be reachable first. If WinDefend is stopped or disabled, for # example because a third-party antivirus owns protection, MpCmdRun fails with # 0x800106ba which is RPC server unavailable. $winDefend = Get-Service -Name 'WinDefend' -ErrorAction SilentlyContinue if ($null -eq $winDefend -or $winDefend.Status -ne 'Running') { $defenderState = 'absent' if ($null -ne $winDefend) { $defenderState = $winDefend.Status } Write-RemediationEvent ('skip: Defender service is {0} so there is no scan to cancel' -f $defenderState) } else { $mpCmdRun = Join-Path $env:ProgramFiles 'Windows Defender\MpCmdRun.exe' # On a patched endpoint the live binary is under the versioned Platform # directory. The Program Files copy can be an older build. $platformRoot = Join-Path $env:ProgramData 'Microsoft\Windows Defender\Platform' if (Test-Path -LiteralPath $platformRoot) { # Sort by parsed version, not by string. A lexical sort orders 4.18.9 # after 4.18.10 because the character 9 is greater than the character 1, # which would pick an older build as the newest. Folders that do not # parse as a version are dropped rather than guessed at. $newest = Get-ChildItem -LiteralPath $platformRoot -Directory -ErrorAction SilentlyContinue | Where-Object { $null -ne ($_.Name -as [version]) } | Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 if ($null -ne $newest) { $candidate = Join-Path $newest.FullName 'MpCmdRun.exe' if (Test-Path -LiteralPath $candidate) { $mpCmdRun = $candidate } } } if (-not (Test-Path -LiteralPath $mpCmdRun)) { Write-RemediationEvent 'skip: MpCmdRun.exe was not found' } else { # The path above is resolved by a directory scan, so the binary is # untrusted until proven otherwise. Verify a valid Microsoft # Authenticode signature before running anything as SYSTEM. Without this # a planted binary under a versioned Platform folder would execute under # a trusted name. $signature = Get-AuthenticodeSignature -FilePath $mpCmdRun -ErrorAction SilentlyContinue $signerOk = $false if ($null -ne $signature -and $signature.Status -eq 'Valid') { if ($null -ne $signature.SignerCertificate) { if ($signature.SignerCertificate.Subject -match 'O=Microsoft Corporation') { $signerOk = $true } } } if (-not $signerOk) { $why = 'unsigned or untrusted' if ($null -ne $signature) { $why = $signature.Status } Write-RemediationEvent ('skip: MpCmdRun.exe failed Microsoft signature check ({0})' -f $why) } else { $result = (& $mpCmdRun -Scan -Cancel 2>&1 | Out-String).Trim() # Collapse to one line and drop characters the runner cannot carry. # This stripping is a workaround for a runner flaw, not a fix. The # runner wraps script content, and captured output, in double quotes # by string concatenation, so a stray double quote or semicolon in # returned text can break out of the wrapper. The real fix is to # carry input and output over a non-string channel such as # -EncodedCommand and CLIXML, which lives in the CRS framework and is # out of scope of this file. $result = $result -replace '[\r\n]+', ' ' # \x22 is a double quote and \x3B is a semicolon. Written as hex so # neither character appears literally in this file. $result = $result -replace '[\x22\x3B]', ' ' Write-RemediationEvent ('mpcmdrun: {0}' -f $result) } } }